探索 Deno 命令行开发解决方案
一、前言
我目前在掘金上正在写一个系列的关于我写的一个 Node 命令行开发框架 Semo 的介绍,对命令行方面的知识有一些研究,而对于 Deno
之前仅止于听说,安装和 Hello world
。正巧昨天看到掘金上正在进行 Deno
方面的征文,我就想从命令行的角度做一些探索再分享出来,参加这次征文,所以,感谢掘金这次征文,让我有了深入研究 Deno
的动机,以及接下来要给大家分享的研究成果。
命令行这件事情说难不难,说简单也不简单,其实命令行应用和其他 Web 应用类似,如果基于框架开发会省一些事,但是要遵循一些框架的规范。用不用框架都能实现,就看你是想学造轮子还是想快速实现业务需求了。
用 Deno
开发命令行和 Node
命令行开发大体类似,但是又有一些区别,开发的时候,让我几乎感觉不到 Deno
,但是在有的地方,又不得不去踩坑,填坑才行。尤其是,我受了之前开发 Semo
时的影响,想着是不是可以用 Deno
做出一个类似的方案出来,结果失败而走上了另一条路。。
这篇文章,我先说成品,再讲过程。
二、Denosh
,Deno 命令行开发解决方案
更新: Deno 的第三方扩展提交方式发生变化,不再是用 PR 的方式,而是改为 webhook 的方式,只要名字不冲突,可以随意提交啦,提交以后不能删除,只能用更新的版本去覆盖。本项目已经提交: https://deno.land/x/denosh
2.1 什么是 denosh
命令行工具开发一般大家会关注几方面:
- 参数和选项解析
- 命令路由
- 帮助信息展示
- ...
基于我的研究成果,我起了一个项目,denosh, 使用 denosh 之后,大家就可以简化这个过程,直接写你想要实现的命令。
2.2 安装和使用
这里有两种方式可以体验,deno install
的方式和 依赖的方式,deno install
的方式是不推荐的方式,但是也可以说一说。
deno install
方式
deno install --allow-read --allow-write -f -n denosh https://deno.land/x/denosh/denosh.ts
目前,还没有通过 PR,所以,这里还是直接从我的项目上拉取。install
的时候要给相关的命令一些权限。
为什么说这种方式不推荐呢,因为这种方式,大家只能看我提供的几个命令,而我提供的命令又没有什么实际用途。虽然不推荐使用,但是也是有意义的,如果大家将来不是基于这个框架开发,而是直接 fork 这个项目,在里面开发,那么就可以用 deno install
的方式发布的。
开发依赖的方式
这种方式就是说,框架不提供入口,而是提供一些 API,然后你开发一个自己的命令行项目依赖 denosh
这个项目。
这里假设你想开发一个命令行,叫 cli
// ./cli.ts
import { launch, registerCommand } from "https://deno.land/x/denosh/mod.ts";
import * as test1Command from "./src/commands/test1.ts";
import * as test2Command from "./src/commands/test2.ts";
registerCommand("test1", test1Command);
registerCommand("test2", test2Command);
if (import.meta.main) {
launch(Deno.args, {
scriptName: "cli",
commandDir: "src/commands",
});
}
代码很好理解,提供了一个启动命令的 API 和一个注册命令的 API。
一旦写了这些代码,就可以看看效果了:
$ deno run -A cli.ts --help
cli [command]
Commands:
cli help Show help
cli version Show version
cli generate <name> [desc] Generate command
cli test2 test2
cli test1 test1
Options:
-h, --help: Show help
-v, --version: Show version
$ deno run -A cli.ts test1 --help
test1
test1
Options:
--opt1, --o1 opt1
其中,version
, help
, generate
是内置的命令,test1
, test2
两个命令是当前项目定义的。那么这里的命令长什么样子呢?
// test1.ts
export const name = "test1";
export const desc = "test1";
export const aliases = "";
export const builder = (option: any) => {
option.set("opt1", { desc: "opt1", alias: "o1" });
};
export const handler = async (argv: any) => {
console.log("Hello world!");
};
了解 Semo
的同学就会知道,这个命令定义方式和 Semo
或者说和 yargs
的定义方式完全一样。
那么这种固定格式的代码我必须手打么?这就是 generate
命令的作用啦:
$ deno run -A cli.ts generate test3 desc
Done!
$ deno run -A cli.ts --help
cli [command]
Commands:
cli help Show help
cli version Show version
cli generate <name> [desc] Generate command
cli test3 desc
cli test2 test2
cli test1 test1
Options:
-h, --help: Show help
-v, --version: Show version
$ deno run -A cli.ts test3
Hello world!
然后,大家就可以为所欲为了,不是嘛?这里要注意,开发的时候,我用的 -A
就是允许所有权限,真正要发布的时候还是要结合实际需要,只赋予最小权限。框架内部用到了 --allow-read
和 --allow-write
。
一旦大家实现了自己的命令,就可以对外发布了,需要在你的 README.md
里告诉别人安装方式,一般是像上面第一种安装方式那样的 deno install
格式。
三、开发过程解析
开发框架的目的是为了使用简单,但是开发过程还是踩了一些坑的,这里给大家整理一些,希望对大家有所启发,尤其是我是为了这次征文现用现学的,基础还不太牢,哈哈。
3.1 命令行参数解析
在 Node
里有很多命令行参数解析的包可以用,我在开发 Semo
时用的是 yargs
,而且 yargs
项目里也有 issue 在讨论是否要做一个 deno
版的问题。但是在我探索的过程中发现是没有必要的,deno
内置的命令行解析就很灵活了。
Deno.args
保存的是命令行输入的原始信息,数组形式,核心的 flags
包里还提供了一个 parse
方法,返回的结果跟 yargs
的很像,
import { parse } from "https://deno.land/std/flags/mod.ts";
const argv = parse(Deno.args);
3.2 启动命令的写法
Deno 里已经原生支持 async, await 了,另外查到建议用 import.meta.main
对入口进行保护。
async launch() {
}
if (import.meta.main) {
launch(Deno.args, {
scriptName: "cli",
commandDir: "src/commands",
});
}
3.3 命令参数的必填和选填与单元测试
这里参考 yargs
,需要对命令实现必填参数和选填参数的格式:cmd <args1> [args2]
, args1
是必填的,args2
是选填的,本来想把 yargs
的解析逻辑搬过来,结果发现耦合太严重,所以就需要自己来实现了,为了解析正确,这里用了 deno test
进行单元测试,这里发一下测试部分的代码。
import {
assertThrows,
assertEquals,
} from "https://deno.land/std/testing/asserts.ts";
import * as Utils from "../src/common/utils.ts";
Deno.test("Utils.parseCommandName", () => {
const parsed1 = Utils.parseCommandName("cmd <arg1> [arg2]", "cmd foo bar");
assertEquals(parsed1, { arg1: "foo", arg2: "bar" });
const parsed2 = Utils.parseCommandName("cmd <arg1> [arg2]", "cmd foo bar");
assertEquals(parsed2, { arg1: "foo", arg2: "bar" });
assertThrows(() => {
Utils.parseCommandName("cmd <arg1> [arg2]", "cmd");
});
});
执行测试
$ deno test tests
running 1 tests
test Utils.parseCommandName ... ok (10ms)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (11ms)
3.4 Typescript 的类型提示
由于是原生 Typescript
,写的时候还是很爽的,而且身不由己的就必须要写类型,感觉自己的 ts
功力无形之中提升了那么一点点,我在里面定义了如下类型,在开发过程中能体会到类型提示的好处,尤其是在重构的时候,改错一点就一片飘红。
export type OptionStructure = {
/** Option description */
desc: string;
/** Option alias, not working for now */
alias?: string;
/** Option default value, not working for now */
default?: string;
};
export type OptionsStructure = {
[key: string]: OptionStructure;
};
export interface OptionMangerInterface {
/** Set option */
set(key: string, value: OptionStructure): void;
/** Get option */
get(key: string): OptionStructure;
/** Get all options */
all(): OptionsStructure;
/** Get all keys of options */
keys(): string[];
}
export type CommandStructure = {
/** Command name */
name: string;
/** Command description */
desc: string;
/** Command option builder */
builder?(option: OptionMangerInterface): void;
/** Command handler */
handler?(argv: NormalArgvStructure): void;
/** alias, for now it's not working */
aliases?: string | string[];
};
export type CommandsStructure = {
[key: string]: CommandStructure;
};
export type ConfigStructure = {
[key: string]: string | number | boolean | undefined;
};
export type MatchStructure = {
[key: string]: string | number | boolean | undefined;
};
export type NormalArgvStructure = {
[key: string]: string | number | boolean | undefined;
};
export type LaunchOptionStructure = {
/** entry script name, used in showing help info */
scriptName?: string;
/** Extra Commands Directory */
commandDir?: string;
};
这些类型也都作为框架的一部分对外暴露了,所以大家在开发过程中可以引入里面的类型对输入输出进行约束,从而也能用到类型提示。
3.5 动态扫描和静态导入
一开始的时候我是想提供一个命令行工具的,像 Semo
一样,大家只需要按照规范格式写一个命令行的 ts 文件,就能识别和运行,但是实际开发才发现,这样行不通,虽然 Deno
的 import
支持动态导入,但是为了安全性限制,不允许动态导入所在项目之外的 ts 文件。所以我在开发中途转变了思路,改成了静态导入和注册的机制。
当然,动态导入的这个限制只是限制了框架不能调度具体业务项目,但是可以把动态扫描和导入放到业务项目中,所以在 cli 这个示例项目,我可以这么写来动态扫描和注册命令。
async function dynamicRegister() {
const commandsDir = "src/commands";
const scannedCommands = [];
for (let entry of Deno.readDirSync(commandsDir)) {
if (entry.isFile && path.extname(entry.name) == ".ts") {
scannedCommands.push(entry);
}
}
for (let entry of scannedCommands) {
const command = await import(path.resolve(commandsDir, entry.name));
registerCommand(path.basename(entry.name, ".ts"), command);
}
}
await dynamicRegister();
3.6 最后再说说提交 PR
由于 PR 提交时有自动 CI 要求必须通过才会收录,所以除了写好 README
之外,还需要确保 deno lint
, deno fmt
和 deno test
都要通过。
被 deno fmt
处理过的代码风格可能和你平时的写法不太一样,所以大家可以放心地让其去格式化,而不是一定要在写的时候就符合 deno
的风格。
deno lint
会对类型有一些基本的要求,比如消除 any
,不管是隐式的还是显式的,这就倒逼我们必须优化类型声明。
deno test
我就写了一个,但是要保证有价值和通过测试也下了一番功夫。
小结
以上就是我用一天时间,从想参加这次征文开始,一路边学边做的收获和成果。
这个项目算是挖的一个坑,远没有达到生产级别,大家别见笑,觉得有启发的同学,求您给个 Star
哈,后面还会继续维护。一些方向:
目前命令和 alias 和 选项的 alias 还没有生效选项的默认值还没有生效- 需要更多的单元测试
- 内置的命令还可以再多一些,开发辅助
内置的命令应该可以开关,业务项目可能不需要在发布的时候还显示这些命令。